feat(news): unified story-identity service — one similarity definition for clustering, dedup, corroboration (#4919)#4924
Conversation
…n for clustering, dedup, and corroboration (#4919) Story identity previously had three inconsistent answers: _clustering.mjs (title-token Jaccard >= 0.5), news/v1/dedup.mjs (word-overlap > 0.6), and list-feed-digest.ts story tracking (EXACT sha256 of the normalized title — any wording edit forked the story, so corroboration only counted verbatim wire syndication, deflating importanceScore's corroboration signal and the BREAKING/DEVELOPING phase tracker). New shared/story-identity.js (+ scripts/shared mirror, d.ts): dual-view feature-hashed lexical vectors — a uniform-weight view and an entity/number-boosted view; similarity = min of the two cosines, so a pair merges only when both views agree. Each view catches the failure mode the other is blind to (action swaps vs one-entity swaps). Threshold 0.615 tuned on a labeled pair set committed as the test-suite ground truth (min positive 0.634, max negative 0.595); known limits (one-token swaps, cross-language paraphrase) documented, with a setStoryVectorProvider seam for a future semantic embedding upgrade. All three call sites now delegate: - _clustering.mjs drops its local Jaccard matcher (insights clustering) - dedup.mjs reimplements deduplicateHeadlines on shared vectors and adds assignStoryIdentity (cluster batch -> canonical titleHash + cluster-wide corroboration count; canonical = hash of lexicographic-min normalized member title, so singleton clusters keep their exact pre-change keys) - list-feed-digest.ts corroboration pass uses assignStoryIdentity; cluster members share one story:track identity and a real multi-wording source count Closes #4919 🤖 Generated with Claude Code Claude-Session: https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR replaces three inconsistent "same story?" definitions (Jaccard-0.5 in clustering, word-overlap-0.6 in dedup, exact-SHA256 in corroboration tracking) with a single module: a dual-view feature-hashed lexical vectorizer with cosine similarity at a threshold tuned on a labeled pair set. The payoff is that
Confidence Score: 4/5The change is safe to merge; the corroboration and hash-key regression tests are thorough and the singleton-key stability guarantee is verified end-to-end. The core vectorizer, assignStoryIdentity, and the list-feed-digest integration all look correct and are well-tested. Two spots warrant a second look: clusterItems in _clustering.mjs re-implements the greedy clustering loop that clusterTexts now exports from the same mirrored module — an algorithm change in one place won't automatically propagate to the other. And assignStoryIdentity passes raw (un-normalized) titles to clusterTexts while using normalizeTitle only for the canonical hash, which creates a slight asymmetry that could suppress clustering for very short headlines whose source-attribution suffix is proportionally significant. scripts/_clustering.mjs (duplicated clustering loop) and server/worldmonitor/news/v1/dedup.mjs (raw-title input to clusterTexts) Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant LFD as list-feed-digest.ts
participant DEDUP as dedup.mjs
participant SI as shared/story-identity.js
participant REDIS as Redis (story:track)
LFD->>DEDUP: assignStoryIdentity(allItems, normalizeTitle, sha256Hex)
DEDUP->>SI: clusterTexts(rawTitles)
SI-->>DEDUP: clusters (index[][])
loop per cluster
DEDUP->>DEDUP: "canonical = min(normalizeTitle(titles)).sort()[0]"
DEDUP->>DEDUP: "titleHash = sha256Hex(canonical)"
DEDUP->>DEDUP: "corroborationCount = uniqueSources.size"
end
DEDUP-->>LFD: "Map<item, {titleHash, corroborationCount}>"
LFD->>LFD: "item.titleHash = identity.titleHash"
LFD->>LFD: "item.corroborationCount = identity.corroborationCount"
LFD->>REDIS: "HINCRBY story:track:{titleHash} mentionCount 1"
LFD->>LFD: computeImportanceScore(corroborationCount x 0.15)
Note over SI: Also used by _clustering.mjs (scripts/shared mirror) and deduplicateHeadlines
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant LFD as list-feed-digest.ts
participant DEDUP as dedup.mjs
participant SI as shared/story-identity.js
participant REDIS as Redis (story:track)
LFD->>DEDUP: assignStoryIdentity(allItems, normalizeTitle, sha256Hex)
DEDUP->>SI: clusterTexts(rawTitles)
SI-->>DEDUP: clusters (index[][])
loop per cluster
DEDUP->>DEDUP: "canonical = min(normalizeTitle(titles)).sort()[0]"
DEDUP->>DEDUP: "titleHash = sha256Hex(canonical)"
DEDUP->>DEDUP: "corroborationCount = uniqueSources.size"
end
DEDUP-->>LFD: "Map<item, {titleHash, corroborationCount}>"
LFD->>LFD: "item.titleHash = identity.titleHash"
LFD->>LFD: "item.corroborationCount = identity.corroborationCount"
LFD->>REDIS: "HINCRBY story:track:{titleHash} mentionCount 1"
LFD->>LFD: computeImportanceScore(corroborationCount x 0.15)
Note over SI: Also used by _clustering.mjs (scripts/shared mirror) and deduplicateHeadlines
|
…union-find clustering, suffix-blind vectors, per-hash tracking writes (#4924 review round) Applies the multi-agent + cross-model review findings on PR #4924: - canonical id anchors on the EARLIEST-published member (reliability P1, conf 100, corroborated by the story:track 3-incident history): a later-joining wording can no longer steal the identity mid-lifecycle and reset mentionCount/firstSeen/phase - clusterTexts uses union-find connected components instead of greedy first-seed assignment (cross-model finding): cluster membership — and therefore the persistent identity — is now input-order independent - source-attribution suffixes are stripped before vectorization (cross-model P1): Google-News wrapper titles carry "- Publisher" on every item, and the entity-boosted publisher token pulled DISTINCT same-wrapper stories toward a false merge - containment rescue (>=90% token containment, min 4 tokens) restores the old dedup metric's guarantee for severely truncated headlines (testing P1, proven regression) - writeStoryTracking runs mutable per-story writes (mentionCount HINCRBY, representative HSET) once per unique hash per cycle with a deterministic representative — per-member writes would inflate mentionCount by N per cycle and skip the DEVELOPING phase (reliability P2 + correctness, promoted on agreement) - emoji/punctuation-only titles get per-item sentinel identities instead of pooling into one sha256("") phantom track (adversarial + testing, conf 100) - hot-token candidate buckets capped at 250 (adversarial perf cascade); identity input clamped to 300 chars (unbounded-title n-gram bomb) - clusterItems delegates to clusterTexts (maintainability P1) — the clustering ALGORITHM is now shared too, not just the similarity - corroboration_count proto doc describes the edit-tolerant semantics; OpenAPI specs regenerated via make generate - coverage-miss fallback logs instead of degrading silently; _clustering.mjs added to the mirror-import guard list New regression tests: canonical stability across sequential builds, order-permutation invariance, truncation + suffix labeled pairs, degenerate-title sentinels, once-per-hash tracking (source-textual), integration wiring assertions. 30/30 in story-identity suite; full affected battery + both typechecks green. Claude-Session: https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Multi-agent code review — applied at b906d90Nine reviewer personas (correctness, testing, maintainability, project-standards, performance, reliability, adversarial, agent-native, learnings) plus an independent cross-model adversarial pass via Codex CLI. All actionable findings applied in the Applied (10 findings)
Pushed back (1)
Residual risks (documented, tracked in follow-up)
Verification30/30 story-identity suite (incl. 6 new regression classes), full affected battery (110 tests), both typechecks, biome, mirror parity, Verdict: Ready for human review — held for manual merge as requested (no auto-merge). |
… hot-bucket caps (#4924 external review) 251 verbatim syndications previously made every shared token bucket exceed MAX_CANDIDATE_BUCKET, so no pairs were scored and the day's most-corroborated story degraded to singletons. Identical normalized texts now union unconditionally before the candidate scan. Claude-Session: https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP
… external review) P1 — non-canonical variants lost cross-cycle continuity, WORSE than the old exact hashing for that sub-case: cycle 1 (A+B, A canonical) wrote only A's track; a cycle-2 B-only batch hashed to B and reset the story to BREAKING/mentionCount=1. Now every cycle persists memberHash -> canonicalHash alias rows (story:alias:v1, story-track TTL) and the digest adopts the live canonical most members point at (deterministic: most-common target, ties lexicographic) before assigning hashes — one batched GET pipeline; failures degrade to batch-derived canonicals. Two-cycle A+B -> B-only regression at the pure level plus wiring pins. P2 — EXPIRE for story:sources/story:peak was queued in the once-per-hash PRE-block, before the per-member SADD/ZADD that CREATE those keys: EXPIRE on a missing key is a no-op, so every brand-new story leaked two persistent keys. The EXPIREs now ride adjacent to the creating writes (idempotent per member); ordering pinned by test. P2 hot-bucket identical-title clustering was already fixed this round (exact-duplicate pre-union, ca80a2c). Pre-existing read/write prefix parity filed as #4936. story-identity 34/34, server-handlers 24, mirror 12, persistence 17, typecheck:api clean. Claude-Session: https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP
External review adjudication — fixed at 82a9409
Batteries: story-identity 34/34, server-handlers 24, mirror 12, track-persistence 17; |
Closes #4919 (Bet 1 of the 2026-07-05 strategic news-pipeline review). Do not auto-merge — held for review.
What this changes
Story identity had three inconsistent answers, and the most load-bearing one was exact-match:
scripts/_clustering.mjs)STORY_SIMILARITY_THRESHOLDnews/v1/dedup.mjs)list-feed-digest.ts:1246)sha256(normalizeTitle)— any wording edit forked the storyassignStoryIdentity: cluster the batch, share one canonical hash + cluster-wide source countThe corroboration change is the payoff:
corroborationCountfeedscomputeImportanceScore(weight 0.15) and the BREAKING/DEVELOPING phase tracker, and under exact hashing it only counted verbatim wire syndication. Now three outlets wording the same event differently count as 3-source corroboration (regression-tested).The similarity method (and its honest limits)
shared/story-identity.js(+scripts/shared/byte-mirror, enforced by the mirror test): dual-view feature-hashed lexical vectors — a uniform-weight view and an entity/number-boosted view (capitalized-in-raw tokens ×3, numeric tokens ×2); similarity = min of the two cosines. Each view catches what the other is blind to: the uniform view separates action swaps ("seizes tanker" vs "threatens to close") that entity weighting washes out; the boosted view separates one-entity swaps ("Turkey hikes rates to 50%" vs "Argentina hikes rates to 50%") that score ~0.82 under flat weights. Features: word tokens, word bigrams (order/direction — separates "Israel strikes Hezbollah" from "Hezbollah strikes Israel"), char 4-grams (morphology: iran/iranian), char bigrams for unsegmented scripts (CJK tested).Threshold 0.615 tuned on a labeled pair set committed as test ground truth: 10 edit-variant positives (min 0.634) vs 9 same-topic-different-event negatives (max 0.595), with a margin trip-wire test that fails any retune that collapses the separation.
Documented limits: (1) two events differing by one unboosted token ("China exports fall" vs "China imports fall", "12th" vs "13th sanctions package") are not separable by any lexical similarity — the 96h window and entity-corroboration signals bound the damage; (2) cross-language paraphrase needs a semantic embedding — the
setStoryVectorProvider()seam accepts one without touching any consumer.Migration safety
Verification
tests/story-identity.test.mjs(20 tests): labeled-pair separation + margin trip-wire, CJK, all-caps, determinism, provider seam,assignStoryIdentityacceptance (corroboration-rises regression, singleton key stability, order independence, same-outlet-counts-once).typecheck+typecheck:api+ biome +docs-stats --checkclean on today's origin/main.https://claude.ai/code/session_01WmhkGjZrb9RbV7pV3muFxP